[JVM] 垃圾收集器与内存分配策略


 垃圾收集器与内存分配策略

最早人们思考GC需要完成的3件事情:

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

经过发展,内存动态分配和回收技术已经成熟,为什么还要了解GC和内存分配呢?
当需要排查各种内存溢出,内存泄露问题时,当垃圾手机成为系统达到更高并发量的瓶颈时,就需要人为对其进行监控和调节.

前面提到的程序计数器,虚拟机栈,本地方法栈3个区域随线程生和灭,每个栈帧分配内存基本上是在类结构确定后就已知的.因此方法结束或者线程结束时,内存自然就跟着回收了.

Java堆方法区则不一样:

  • 一个接口中多个实现类需要的内存可能不一样
  • 一个方法中的多个分支需要的内存也可能不一样

我们只有在程序运行期间才知道会创建哪些对象,这部分内存分配和回收都是动态的,垃圾收集器所关注的就是这部分内存.

对象已死吗

中存放着Java中几乎所有的对象实例,垃圾收集器对回收前,第一件事情是要确定这些对象哪些还活着.

引用计数法 Reference Counting

给对象一个引用计数器,对它引用时,计数器加1;引用失效,计数器减1.任何时刻,计数为0的对象是不可能再被使用的.

引用计数法实现简单,判定效率高,但很难解决对象之间循环引用问题.所以JVM没有使用这个方法.

可达性分析算法 Reachability Analysis

基本思路是,通过一系列称为GC Roots的对象作为起始点,从节点向下搜索.搜索走过的路径称为引用链 Reference Chain,当一个对象到GC Roots没有任何引用链相连时,证明此对象不可用.

下图白色对象就是可回收的对象.

可作为GC Roots的对象包括:

  • 虚拟机栈(栈帧中本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象
再谈引用

JDK1.2以后,对引用概念进行扩充,将引用分为:

  • 强引用 Strong Reference

    类似A obj = new A()这类普遍存在的引用.只要强引用还在,垃圾收集器永远不会回收掉被引用的对象.

  • 软引用 Soft Reference

    SoftReference类实现.表示一些有用但并非必需的对象.

    对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围中进行二次回收.如果这次回收还没有足够内存,抛出OOM.

  • 弱引用 Weak Reference

    WeakReference类实现.表示非必需的对象,但强度比软引用弱.

    弱引用关联的对象只能生存到下一次垃圾收集发生之前.收集器一旦工作,就会回收掉只被弱引用关联的对象.无关内存情况.

  • 虚引用 Phantom Reference

    PhantomReference类实现虚引用.它是最弱的引用关系.

    一个对象是否有虚引用不对其生存时间产生影响,无法通过虚引用取得一个对象实例.

    其存在的唯一目的,是对象被回收时收到一个系统通知.

生存or死亡

可达性分析算法中找到的可回收的对象,会被第一次标记并进行一次筛选.条件是此对象是否有必要执行finalize()方法.

当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将认为没有必要触发该方法.(finalize()方法最多只被自动调用一次)

如果这个对象被判定为有必要执行finalize()方法,对象就被放入F-Queue队列,等待Finalizer线程执行.

finalize()方法中GC将对F-Queue中对象进行第二次标记,这个阶段对象只要重新与引用链上任何一个对象建立关联即可不被标记,从而”活”下来.

下面用代码演示finalize()过程:

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("I am alive!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();

        //first time save itself
        SAVE_HOOK = null;
        System.gc();
        // finalize priority is low, so wait it
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("I am dead ...");
        }

        //second time. same code but save failed
        SAVE_HOOK = null;
        System.gc();
        // finalize priority is low, so wait it
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("I am dead ...");
        }
    }
}

运行结果:

finalize method executed!
I am alive!
I am dead ...

代码能看到,finalize()方法有被触发过.至于第二次自救失败,是因为任何对象的finalize()方法只被系统自动调用一次.对象面临下一次回收,此方法不会被再次执行.

finalize()方法不建议使用,因为运行代价高,不确定性强,无法保证各对象的调用顺序.

回收方法区

之前提到过,方法区可以不实现垃圾回收,而且这里的回收”性价比”非常低(对比).

方法区回收主要是两部分内容:

  • 废弃常量
  • 无用的类

回收常量和回收Java类似,没有对这个常量有引用的情况就可以回收.

回收无用的类就要判断以下:

  • Java堆中该类的所有实例都已经被回收
  • 加载该类的ClassLoader已经回收
  • 该类对应的java.lang.Class对象没有被引用,无法通过反射访问该类的方法

垃圾收集算法

仅做原理介绍.

标记-清除 Mark-Sweep

先标记所有需要回收的对象,然后统一回收.

不足:

  • 效率过低.标记和清除都不高效
  • 空间碎片过多.清除标记后产生大量不连续内存碎片.如果以后要再分配大对象时,无法找到足够的连续内存会导致再次触发垃圾回收.
复制 Copying

将内存按容量分为相等的两块A和B.每次只使用一块,比如A,这一块内存用完了,就对A进行回收,把存活的对象复制到B上,然后把A一次清理掉.

这样没有内存碎片,按顺序移动堆指针,很高效.

不足:

  • 但内存缩小了一半.所以一些优化是把1:1改成9:1或者8:1这样分配.
  • 对象存活率高时,复制操作效率低.
标记整理 Mark-Compact

标记还是和之前一样,但清除前,先将存活对象移到同一端,然后清理掉边界外的内存.

分代收集 Generational Collection

根据对象存活周期将内存分为几块,根据特点选算法.一般分为新生代,老年代:

  • 新生代采用复制算法
  • 老年代使用标记清理标记整理

HotSpot 虚拟机算法实现

从可达性分析入手,我们首先需要找到GC Roots.这个GC Roots主要存在于全局性引用与执行上下文中.但现在很多应用在方法区都有数百兆,直接检查很耗时.

另外,这项工作在分析期间,系统需要暂停,即分析时保证状态不会变化.

在系统暂停期间,虚拟机从OopMap直接获得对象引用,不需要一个不漏地检查完所有执行上下文和全局的引用位置.

能引起OopMap内容变化的指令很多,但HotSpot并没有为每条指令都生成OopMap,只是在特定位置记录这些信息,这个位置称为安全点Safepoint.

安全点不能太多也不能太少,权衡标准就是:是否具有让程序长时间执行的特征.所以长指令流的指令才会产生安全点.

安全点保证程序执行时,可以进入GC的安全点,但是程序不执行的时候,即线程处于Sleep或者Blocked时,线程无法响应JVM中断请求.这就需要安全区Safe Region解决.

线程进入安全区中,就会标识自己,JVM发起GC就会忽略有标记的线程.

线程离开安全区,先检查系统是否完成根节点枚举或整个GC,如果完成,线程就继续执行,否则等待直到收到可以离开的信号.

垃圾收集器

垃圾收集器的实现没有统一的规定,所以有很多种不同实现.

这里仅列举常见的收集器.

  • Serial
  • ParNew
  • Parallel Scavenge
  • Serial Old
  • Parallel Old
  • CMS
  • G1
理解GC日志
33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]
100.667: [Full GC [Tenured: OK->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
  • 33.125,100.667

    代表从JVM启动开始,GC发生的时间点.

  • [GC[Full GC

    说明垃圾收集的停顿类型.如果有Full,说明这次GC发生Stop-The-World(系统暂停).

    如果调用System.gc(),则log显示[Full GC(System)

  • [DefNew,[Tenured,[Perm

    表示GC发生的区域.

    Serial收集器新生代名为Default New Generation,所以显示[DefNew

  • 内存区域括号内的 3324K->152K(3712K)

    GC前该内存区已使用容量->GC后该内存区域已使用容量(该内存区域总容量)

  • GC括号内的 3324K->152K(11904K)

    GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)

  • 0.0025925 secs

    该区域GC所用时间

  • [Times: user=0.01 sys=0.00, real=0.02 secs]

    和linux时间含义一致,用户消耗CPU时间,内核消耗CPU时间和操作从开始到结束经过墙钟时间

内存分配与回收策略

对象的内存分配,往大方面讲,是在堆上面分配.对象主要分配在新生代的Eden区上.

普遍的分配规则:

  • 对象优先在Eden分配

    比如,20MB内存,新生代10MB,老年代10MB.新生代又分为8MBEden区和1MBSurvivor区.

    新建6M对象,存在Eden区,再存入4MB对象,这时Eden区空间不足,遂计划将6MB移入Survivor区,但Survivor区内存也不够,于是这6MB就移入老年代.

  • 大对象直接进入老年代

    大对象就是需要连续内存空间的Java对象.比如超长的字符串或数组.平时需要避免大对象,因为系统会为了腾出足够的连续空间而提前GC.

    大对象进入老年代就是因为新生代GC很频繁,降低负担.

  • 长期存活的对象将进入老年代

    这个好理解吧?每一次经过GC存活的对象,年龄都加1.默认阈值是15,到15岁就进入老年代.

  • 动态对象年龄判定

    15岁的阈值规定也不是永远强制的.如果Survivor中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可直接进入老年代.

  • 空间分配担保

    发生Minor GC(新生代GC)之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间.

    如果成立,则Minor GC是安全的.

    否则,查看HandlePromotionFailure设置值是否允许担保失败.

    如果允许,继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小.

    如果大于,尝试一次Minor GC

    如果小于,改为进行一次Full GC

    如果不允许,改为进行一次Full GC


文章作者: Wossoneri
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC 4.0 许可协议。转载请注明来源 Wossoneri !
评论
  目录